經過 Day 25 的用戶認證系統建置,我們已經有了完整的登入/註冊機制。今天我們要為 Kyo System 增加企業級的多因素認證 (MFA) 與帳號安全強化功能。在現代 SaaS 產品中,單純的密碼認證早就不夠安全,我們需要 MFA、裝置信任管理、登入歷史追蹤等多層防護,來保護用戶的敏感資料。
/**
* 多因素認證 (MFA) 完整架構
*
* ┌─────────────────────────────────────────────┐
* │ MFA Authentication Flow │
* └─────────────────────────────────────────────┘
*
* 第一階段:密碼認證
* ↓
* 第二階段:選擇 MFA 方式
* ├─ TOTP (Time-based OTP)
* │ └─ Google Authenticator / Authy
* ├─ SMS OTP
* │ └─ 手機簡訊驗證碼
* └─ 備份碼
* └─ 一次性恢復碼
* ↓
* 驗證成功 → 建立會話
* ├─ 記住此裝置(30天免 MFA)
* └─ 記錄登入歷史
*
* MFA 設定流程:
* 1. 掃描 QR Code
* 2. 輸入驗證碼確認
* 3. 保存備份碼
* 4. MFA 啟用
*
* 安全特性:
* ✅ TOTP 基於 RFC 6238 標準
* ✅ 備份碼加密儲存
* ✅ 裝置指紋識別
* ✅ 異常登入偵測
* ✅ 登入歷史審計
* ✅ 會話管理
*/
// src/pages/Security/MFASetup.tsx
import { useState, useEffect } from 'react';
import {
Container,
Paper,
Title,
Text,
Stepper,
Button,
Group,
Stack,
PinInput,
Alert,
Code,
CopyButton,
ActionIcon,
Tooltip,
Center,
Box,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import {
IconShield,
IconCheck,
IconCopy,
IconAlertTriangle,
IconDownload,
} from '@tabler/icons-react';
import { QRCodeSVG } from 'qrcode.react';
import { authenticator } from 'otpauth';
import { useAuth } from '../../contexts/AuthContext';
import { notifications } from '@mantine/notifications';
interface MFASetupData {
secret: string;
qrCodeUrl: string;
backupCodes: string[];
}
export function MFASetupPage() {
const { user } = useAuth();
const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false);
const [mfaData, setMfaData] = useState<MFASetupData | null>(null);
const [verificationCode, setVerificationCode] = useState('');
/**
* 步驟 1: 生成 TOTP Secret 與 QR Code
*/
const initializeMFA = async () => {
try {
setLoading(true);
// 呼叫 API 生成 MFA Secret
const response = await fetch('/api/auth/mfa/initialize', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
'Content-Type': 'application/json',
},
});
const data = await response.json();
setMfaData({
secret: data.secret,
qrCodeUrl: data.qrCodeUrl,
backupCodes: data.backupCodes,
});
setActive(1);
} catch (error: any) {
notifications.show({
title: '初始化失敗',
message: error.message || '無法生成 MFA 設定',
color: 'red',
});
} finally {
setLoading(false);
}
};
/**
* 步驟 2: 驗證 TOTP 碼
*/
const verifyTOTP = async () => {
if (verificationCode.length !== 6) {
notifications.show({
title: '驗證碼錯誤',
message: '請輸入 6 位數驗證碼',
color: 'red',
});
return;
}
try {
setLoading(true);
const response = await fetch('/api/auth/mfa/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
code: verificationCode,
secret: mfaData?.secret,
}),
});
if (!response.ok) {
throw new Error('驗證碼錯誤');
}
notifications.show({
title: '驗證成功',
message: 'TOTP 驗證成功',
color: 'green',
});
setActive(2);
} catch (error: any) {
notifications.show({
title: '驗證失敗',
message: error.message || '驗證碼錯誤,請重試',
color: 'red',
});
} finally {
setLoading(false);
}
};
/**
* 步驟 3: 啟用 MFA
*/
const enableMFA = async () => {
try {
setLoading(true);
const response = await fetch('/api/auth/mfa/enable', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
secret: mfaData?.secret,
}),
});
if (!response.ok) {
throw new Error('啟用失敗');
}
notifications.show({
title: 'MFA 已啟用',
message: '您的帳號已受到雙因素驗證保護',
color: 'green',
icon: <IconShield />,
});
setActive(3);
} catch (error: any) {
notifications.show({
title: '啟用失敗',
message: error.message || '無法啟用 MFA',
color: 'red',
});
} finally {
setLoading(false);
}
};
/**
* 下載備份碼
*/
const downloadBackupCodes = () => {
if (!mfaData?.backupCodes) return;
const content = `Kyo System - MFA 備份碼\n\n` +
`帳號: ${user?.email}\n` +
`生成時間: ${new Date().toLocaleString()}\n\n` +
`備份碼(每個只能使用一次):\n` +
mfaData.backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n') +
`\n\n請妥善保管此文件,不要分享給任何人。`;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `kyo-mfa-backup-codes-${Date.now()}.txt`;
link.click();
URL.revokeObjectURL(url);
notifications.show({
title: '備份碼已下載',
message: '請妥善保管此文件',
color: 'blue',
});
};
return (
<Container size={600} my={40}>
<Paper radius="md" p="xl" withBorder>
<Title order={2} mb="xl">
設定雙因素驗證 (MFA)
</Title>
<Stepper active={active} onStepClick={setActive} breakpoint="sm">
{/* 步驟 1: 介紹 */}
<Stepper.Step label="開始設定" description="了解 MFA">
<Stack spacing="md" py="xl">
<Alert icon={<IconShield size={16} />} title="為什麼需要 MFA?" color="blue">
雙因素驗證能在密碼外加上額外的安全層,即使密碼被盜,攻擊者仍無法登入您的帳號。
</Alert>
<Text>
<strong>MFA 如何運作?</strong>
</Text>
<Text size="sm" color="dimmed">
1. 使用 Google Authenticator 或 Authy 等驗證應用程式
<br />
2. 掃描 QR Code 綁定您的帳號
<br />
3. 登入時輸入驗證應用程式產生的 6 位數驗證碼
<br />
4. 每 30 秒驗證碼會自動更新
</Text>
<Button
onClick={initializeMFA}
loading={loading}
leftIcon={<IconShield size={18} />}
>
開始設定 MFA
</Button>
</Stack>
</Stepper.Step>
{/* 步驟 2: 掃描 QR Code */}
<Stepper.Step label="掃描 QR Code" description="使用驗證應用程式">
{mfaData && (
<Stack spacing="md" py="xl">
<Text>
使用 <strong>Google Authenticator</strong> 或 <strong>Authy</strong>
掃描此 QR Code
</Text>
<Center>
<Box p="md" style={{ background: 'white', borderRadius: 8 }}>
<QRCodeSVG
value={mfaData.qrCodeUrl}
size={200}
level="H"
includeMargin
/>
</Box>
</Center>
<Alert icon={<IconAlertTriangle size={16} />} color="yellow">
無法掃描?手動輸入此金鑰:
<Code block mt="xs">
{mfaData.secret}
</Code>
<Group position="right" mt="xs">
<CopyButton value={mfaData.secret}>
{({ copied, copy }) => (
<Tooltip label={copied ? '已複製' : '複製'}>
<ActionIcon onClick={copy} variant="subtle">
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
</Alert>
<Text>掃描完成後,輸入驗證應用程式顯示的 6 位數驗證碼:</Text>
<Center>
<PinInput
length={6}
type="number"
value={verificationCode}
onChange={setVerificationCode}
size="lg"
placeholder=""
/>
</Center>
<Button
onClick={verifyTOTP}
loading={loading}
disabled={verificationCode.length !== 6}
fullWidth
>
驗證並繼續
</Button>
</Stack>
)}
</Stepper.Step>
{/* 步驟 3: 保存備份碼 */}
<Stepper.Step label="保存備份碼" description="重要!">
{mfaData && (
<Stack spacing="md" py="xl">
<Alert icon={<IconAlertTriangle size={16} />} color="orange" title="重要!">
以下備份碼用於在無法使用驗證應用程式時恢復存取。
每個備份碼只能使用一次,請妥善保管。
</Alert>
<Paper p="md" withBorder>
<Stack spacing="xs">
{mfaData.backupCodes.map((code, index) => (
<Group key={index} position="apart">
<Code>{code}</Code>
<CopyButton value={code}>
{({ copied, copy }) => (
<Tooltip label={copied ? '已複製' : '複製'}>
<ActionIcon onClick={copy} variant="subtle" size="sm">
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
))}
</Stack>
</Paper>
<Button
variant="outline"
leftIcon={<IconDownload size={18} />}
onClick={downloadBackupCodes}
>
下載備份碼
</Button>
<Button onClick={enableMFA} loading={loading} fullWidth>
我已保存備份碼,啟用 MFA
</Button>
</Stack>
)}
</Stepper.Step>
{/* 步驟 4: 完成 */}
<Stepper.Completed>
<Stack spacing="md" py="xl" align="center">
<IconShield size={64} color="green" />
<Title order={3}>MFA 已成功啟用!</Title>
<Text color="dimmed" ta="center">
您的帳號現在受到雙因素驗證保護。
下次登入時,您需要輸入驗證應用程式產生的驗證碼。
</Text>
<Button onClick={() => window.location.href = '/dashboard'}>
返回 Dashboard
</Button>
</Stack>
</Stepper.Completed>
</Stepper>
</Paper>
</Container>
);
}
// src/pages/Auth/MFAVerifyPage.tsx
import { useState } from 'react';
import {
Container,
Paper,
Title,
Text,
Stack,
PinInput,
Button,
Alert,
Anchor,
Group,
Checkbox,
Center,
} from '@mantine/core';
import { IconShield, IconAlertCircle } from '@tabler/icons-react';
import { useNavigate, useLocation } from 'react-router-dom';
import { notifications } from '@mantine/notifications';
export function MFAVerifyPage() {
const navigate = useNavigate();
const location = useLocation();
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [trustDevice, setTrustDevice] = useState(false);
const [showBackupCode, setShowBackupCode] = useState(false);
// 從前一頁取得暫時 token
const tempToken = location.state?.tempToken;
const handleVerify = async () => {
if (code.length !== 6) {
notifications.show({
title: '驗證碼錯誤',
message: '請輸入 6 位數驗證碼',
color: 'red',
});
return;
}
try {
setLoading(true);
const response = await fetch('/api/auth/mfa/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
tempToken,
code,
trustDevice,
isBackupCode: showBackupCode,
}),
});
if (!response.ok) {
throw new Error('驗證碼錯誤');
}
const data = await response.json();
// 儲存 Access Token
localStorage.setItem('accessToken', data.accessToken);
// 如果選擇信任此裝置,儲存裝置 token
if (trustDevice && data.deviceToken) {
localStorage.setItem('deviceToken', data.deviceToken);
}
notifications.show({
title: '登入成功',
message: '歡迎回來!',
color: 'green',
});
navigate('/dashboard');
} catch (error: any) {
notifications.show({
title: '驗證失敗',
message: error.message || '驗證碼錯誤,請重試',
color: 'red',
});
setCode('');
} finally {
setLoading(false);
}
};
return (
<Container size={420} my={80}>
<Paper radius="md" p="xl" withBorder>
<Center mb="xl">
<IconShield size={48} color="blue" />
</Center>
<Title order={2} ta="center" mb="md">
雙因素驗證
</Title>
<Text c="dimmed" size="sm" ta="center" mb="xl">
{showBackupCode
? '輸入您的備份碼'
: '請輸入驗證應用程式顯示的 6 位數驗證碼'
}
</Text>
<Stack spacing="md">
<Center>
<PinInput
length={showBackupCode ? 8 : 6}
type={showBackupCode ? 'text' : 'number'}
value={code}
onChange={setCode}
size="lg"
placeholder=""
oneTimeCode
/>
</Center>
<Checkbox
label="信任此裝置 30 天(不建議在公用電腦上使用)"
checked={trustDevice}
onChange={(e) => setTrustDevice(e.currentTarget.checked)}
/>
<Button
onClick={handleVerify}
loading={loading}
disabled={code.length < (showBackupCode ? 8 : 6)}
fullWidth
>
驗證
</Button>
<Group position="center">
<Anchor
size="sm"
onClick={() => setShowBackupCode(!showBackupCode)}
>
{showBackupCode ? '使用驗證應用程式' : '使用備份碼'}
</Anchor>
</Group>
{showBackupCode && (
<Alert icon={<IconAlertCircle size={16} />} color="yellow">
每個備份碼只能使用一次。使用後請立即重新生成備份碼。
</Alert>
)}
</Stack>
</Paper>
</Container>
);
}
// src/pages/Security/SecuritySettings.tsx
import { useState, useEffect } from 'react';
import {
Container,
Paper,
Title,
Text,
Stack,
Group,
Button,
Badge,
Switch,
Timeline,
Table,
ActionIcon,
Modal,
Alert,
Divider,
ThemeIcon,
Progress,
Card,
SimpleGrid,
} from '@mantine/core';
import {
IconShield,
IconDevices,
IconHistory,
IconKey,
IconTrash,
IconMapPin,
IconClock,
IconAlertTriangle,
IconCheck,
IconX,
} from '@tabler/icons-react';
import { useAuth } from '../../contexts/AuthContext';
import { notifications } from '@mantine/notifications';
import { formatDistanceToNow } from 'date-fns';
import { zhTW } from 'date-fns/locale';
interface SecurityDevice {
id: string;
name: string;
deviceType: string;
browser: string;
os: string;
ipAddress: string;
location?: string;
lastAccessAt: Date;
isCurrent: boolean;
trusted: boolean;
}
interface LoginHistory {
id: string;
timestamp: Date;
ipAddress: string;
location?: string;
device: string;
success: boolean;
mfaUsed: boolean;
}
export function SecuritySettingsPage() {
const { user } = useAuth();
const [mfaEnabled, setMfaEnabled] = useState(false);
const [devices, setDevices] = useState<SecurityDevice[]>([]);
const [loginHistory, setLoginHistory] = useState<LoginHistory[]>([]);
const [loading, setLoading] = useState(true);
const [revokeModalOpen, setRevokeModalOpen] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<SecurityDevice | null>(null);
useEffect(() => {
fetchSecurityData();
}, []);
const fetchSecurityData = async () => {
try {
setLoading(true);
const [mfaRes, devicesRes, historyRes] = await Promise.all([
fetch('/api/auth/mfa/status', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
},
}),
fetch('/api/auth/devices', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
},
}),
fetch('/api/auth/login-history', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
},
}),
]);
const mfaData = await mfaRes.json();
const devicesData = await devicesRes.json();
const historyData = await historyRes.json();
setMfaEnabled(mfaData.enabled);
setDevices(devicesData.devices);
setLoginHistory(historyData.history);
} catch (error) {
console.error('Failed to fetch security data:', error);
} finally {
setLoading(false);
}
};
const handleToggleMFA = async () => {
if (mfaEnabled) {
// 停用 MFA
const confirmed = window.confirm('確定要停用雙因素驗證嗎?這會降低您的帳號安全性。');
if (!confirmed) return;
try {
const response = await fetch('/api/auth/mfa/disable', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
},
});
if (response.ok) {
setMfaEnabled(false);
notifications.show({
title: 'MFA 已停用',
message: '雙因素驗證已關閉',
color: 'yellow',
});
}
} catch (error) {
notifications.show({
title: '操作失敗',
message: '無法停用 MFA',
color: 'red',
});
}
} else {
// 啟用 MFA - 導向設定頁面
window.location.href = '/security/mfa-setup';
}
};
const handleRevokeDevice = async (deviceId: string) => {
try {
const response = await fetch(`/api/auth/devices/${deviceId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
},
});
if (response.ok) {
setDevices(devices.filter(d => d.id !== deviceId));
setRevokeModalOpen(false);
notifications.show({
title: '裝置已移除',
message: '該裝置的存取權限已被撤銷',
color: 'green',
});
}
} catch (error) {
notifications.show({
title: '操作失敗',
message: '無法撤銷裝置',
color: 'red',
});
}
};
// 計算安全分數
const calculateSecurityScore = (): number => {
let score = 0;
if (user?.emailVerified) score += 20;
if (mfaEnabled) score += 40;
if (devices.filter(d => d.trusted).length <= 3) score += 20;
const recentLogins = loginHistory.filter(h =>
h.timestamp > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
);
if (recentLogins.length > 0 && recentLogins.every(h => h.success)) score += 20;
return score;
};
const securityScore = calculateSecurityScore();
const getScoreColor = (score: number): string => {
if (score >= 80) return 'green';
if (score >= 60) return 'yellow';
return 'red';
};
return (
<Container size="lg" my={40}>
<Stack spacing="xl">
<div>
<Title order={2} mb="xs">
帳號安全
</Title>
<Text color="dimmed">管理您的安全設定、裝置與登入歷史</Text>
</div>
{/* 安全分數 */}
<Card withBorder padding="lg">
<Group position="apart" mb="md">
<div>
<Text weight={500}>安全分數</Text>
<Text size="sm" color="dimmed">
您的帳號安全等級
</Text>
</div>
<ThemeIcon size="xl" radius="xl" variant="light" color={getScoreColor(securityScore)}>
<IconShield size={24} />
</ThemeIcon>
</Group>
<Progress
value={securityScore}
color={getScoreColor(securityScore)}
size="lg"
mb="xs"
/>
<Text size="sm" color="dimmed">
{securityScore}/100 分
{securityScore < 80 && ' - 建議啟用 MFA 以提升安全性'}
</Text>
</Card>
{/* MFA 設定 */}
<Paper withBorder p="lg">
<Group position="apart" mb="md">
<div>
<Group spacing="xs" mb={4}>
<Text weight={500}>雙因素驗證 (MFA)</Text>
{mfaEnabled ? (
<Badge color="green" size="sm">已啟用</Badge>
) : (
<Badge color="red" size="sm">未啟用</Badge>
)}
</Group>
<Text size="sm" color="dimmed">
為您的帳號增加額外的安全層
</Text>
</div>
<Switch
checked={mfaEnabled}
onChange={handleToggleMFA}
size="lg"
/>
</Group>
{!mfaEnabled && (
<Alert icon={<IconAlertTriangle size={16} />} color="orange">
建議啟用 MFA 以保護您的帳號。即使密碼被盜,攻擊者也無法登入。
</Alert>
)}
</Paper>
{/* 信任的裝置 */}
<Paper withBorder p="lg">
<Group position="apart" mb="md">
<div>
<Text weight={500}>信任的裝置</Text>
<Text size="sm" color="dimmed">
管理您已登入的裝置
</Text>
</div>
<Badge>{devices.length} 個裝置</Badge>
</Group>
<Stack spacing="md">
{devices.map((device) => (
<Card key={device.id} withBorder padding="md">
<Group position="apart">
<div style={{ flex: 1 }}>
<Group spacing="xs" mb={4}>
<IconDevices size={18} />
<Text weight={500}>{device.browser} - {device.os}</Text>
{device.isCurrent && (
<Badge color="blue" size="sm">目前裝置</Badge>
)}
{device.trusted && (
<Badge color="green" size="sm">信任</Badge>
)}
</Group>
<Group spacing="lg">
<Group spacing={4}>
<IconMapPin size={14} />
<Text size="xs" color="dimmed">
{device.location || device.ipAddress}
</Text>
</Group>
<Group spacing={4}>
<IconClock size={14} />
<Text size="xs" color="dimmed">
{formatDistanceToNow(new Date(device.lastAccessAt), {
addSuffix: true,
locale: zhTW,
})}
</Text>
</Group>
</Group>
</div>
{!device.isCurrent && (
<ActionIcon
color="red"
variant="subtle"
onClick={() => {
setSelectedDevice(device);
setRevokeModalOpen(true);
}}
>
<IconTrash size={18} />
</ActionIcon>
)}
</Group>
</Card>
))}
</Stack>
</Paper>
{/* 登入歷史 */}
<Paper withBorder p="lg">
<Text weight={500} mb="md">
登入歷史
</Text>
<Timeline active={-1} bulletSize={24} lineWidth={2}>
{loginHistory.slice(0, 10).map((log) => (
<Timeline.Item
key={log.id}
bullet={log.success ? <IconCheck size={12} /> : <IconX size={12} />}
title={
<Group spacing="xs">
<Text size="sm">
{log.success ? '成功登入' : '登入失敗'}
</Text>
{log.mfaUsed && (
<Badge size="xs" color="blue">MFA</Badge>
)}
</Group>
}
>
<Text size="xs" color="dimmed">
{log.device}
</Text>
<Text size="xs" color="dimmed">
{log.location || log.ipAddress} · {' '}
{formatDistanceToNow(new Date(log.timestamp), {
addSuffix: true,
locale: zhTW,
})}
</Text>
</Timeline.Item>
))}
</Timeline>
</Paper>
{/* 其他安全選項 */}
<Paper withBorder p="lg">
<Text weight={500} mb="md">
其他安全選項
</Text>
<Stack spacing="md">
<Group position="apart">
<div>
<Text size="sm" weight={500}>變更密碼</Text>
<Text size="xs" color="dimmed">定期更新您的密碼</Text>
</div>
<Button variant="light" size="xs">
變更
</Button>
</Group>
<Divider />
<Group position="apart">
<div>
<Text size="sm" weight={500}>登出所有裝置</Text>
<Text size="xs" color="dimmed">撤銷所有裝置的存取權限</Text>
</div>
<Button variant="light" color="red" size="xs">
登出全部
</Button>
</Group>
<Divider />
<Group position="apart">
<div>
<Text size="sm" weight={500}>下載個人資料</Text>
<Text size="xs" color="dimmed">匯出您的所有資料</Text>
</div>
<Button variant="light" size="xs">
下載
</Button>
</Group>
</Stack>
</Paper>
</Stack>
{/* 撤銷裝置確認 Modal */}
<Modal
opened={revokeModalOpen}
onClose={() => setRevokeModalOpen(false)}
title="撤銷裝置存取"
>
<Stack spacing="md">
<Alert icon={<IconAlertTriangle size={16} />} color="orange">
此操作將登出該裝置,您需要重新登入才能繼續使用。
</Alert>
{selectedDevice && (
<div>
<Text size="sm" weight={500}>裝置資訊:</Text>
<Text size="sm" color="dimmed">
{selectedDevice.browser} - {selectedDevice.os}
</Text>
<Text size="sm" color="dimmed">
{selectedDevice.location || selectedDevice.ipAddress}
</Text>
</div>
)}
<Group position="right">
<Button variant="subtle" onClick={() => setRevokeModalOpen(false)}>
取消
</Button>
<Button
color="red"
onClick={() => selectedDevice && handleRevokeDevice(selectedDevice.id)}
>
確認撤銷
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}
// src/utils/device-fingerprint.ts
import FingerprintJS from '@fingerprintjs/fingerprintjs';
/**
* 裝置指紋服務
*
* 用途:
* - 識別用戶裝置
* - 檢測異常登入
* - 實現「記住此裝置」功能
*/
export class DeviceFingerprintService {
private static instance: DeviceFingerprintService;
private fpPromise: Promise<any>;
private constructor() {
// 初始化 FingerprintJS
this.fpPromise = FingerprintJS.load();
}
static getInstance(): DeviceFingerprintService {
if (!DeviceFingerprintService.instance) {
DeviceFingerprintService.instance = new DeviceFingerprintService();
}
return DeviceFingerprintService.instance;
}
/**
* 取得裝置指紋
*/
async getFingerprint(): Promise<string> {
const fp = await this.fpPromise;
const result = await fp.get();
return result.visitorId;
}
/**
* 取得裝置資訊
*/
getDeviceInfo(): {
browser: string;
os: string;
device: string;
screenResolution: string;
timezone: string;
language: string;
} {
const userAgent = navigator.userAgent;
const platform = navigator.platform;
// 簡化的瀏覽器檢測
let browser = 'Unknown';
if (userAgent.includes('Firefox')) browser = 'Firefox';
else if (userAgent.includes('Chrome')) browser = 'Chrome';
else if (userAgent.includes('Safari')) browser = 'Safari';
else if (userAgent.includes('Edge')) browser = 'Edge';
// 簡化的 OS 檢測
let os = 'Unknown';
if (platform.includes('Win')) os = 'Windows';
else if (platform.includes('Mac')) os = 'macOS';
else if (platform.includes('Linux')) os = 'Linux';
else if (/Android/.test(userAgent)) os = 'Android';
else if (/iPhone|iPad/.test(userAgent)) os = 'iOS';
// 裝置類型
const isMobile = /Mobi|Android/i.test(userAgent);
const isTablet = /Tablet|iPad/i.test(userAgent);
let device = 'Desktop';
if (isTablet) device = 'Tablet';
else if (isMobile) device = 'Mobile';
return {
browser,
os,
device,
screenResolution: `${window.screen.width}x${window.screen.height}`,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
language: navigator.language,
};
}
/**
* 檢查是否為信任的裝置
*/
async isTrustedDevice(): Promise<boolean> {
const fingerprint = await this.getFingerprint();
const trustedDevices = JSON.parse(
localStorage.getItem('trustedDevices') || '[]'
);
return trustedDevices.includes(fingerprint);
}
/**
* 將裝置標記為信任
*/
async trustDevice(): Promise<void> {
const fingerprint = await this.getFingerprint();
const trustedDevices = JSON.parse(
localStorage.getItem('trustedDevices') || '[]'
);
if (!trustedDevices.includes(fingerprint)) {
trustedDevices.push(fingerprint);
localStorage.setItem('trustedDevices', JSON.stringify(trustedDevices));
}
}
/**
* 移除裝置信任
*/
async untrustDevice(): Promise<void> {
const fingerprint = await this.getFingerprint();
const trustedDevices = JSON.parse(
localStorage.getItem('trustedDevices') || '[]'
);
const filtered = trustedDevices.filter((id: string) => id !== fingerprint);
localStorage.setItem('trustedDevices', JSON.stringify(filtered));
}
}
// 使用範例
export const deviceFingerprint = DeviceFingerprintService.getInstance();
我們今天完成了 Kyo System 的企業級帳號安全系統:
TOTP vs SMS OTP:
裝置信任機制:
安全分數計算:
備份碼最佳實踐: